[Typescript] TiDB CloudとMomentoを組み合わせてRead-Asideを実装してみる [serverless]

[Typescript] TiDB CloudとMomentoを組み合わせてRead-Asideを実装してみる [serverless]

Clock Icon2023.07.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Introduction

TiDBはMySQL互換があり、OLTP(トランザクション処理など)とOLAP(分析クエリなど)の
両ワークロードを実現可能な、幅広いユースケースに対応可能なデータベースです。
このデータベースはキャッシュサービスのMomentoと相性がよく、
管理コストを抑えつつ素晴らしいパフォーマンスを発揮します。

今回はここにある記事を参考に、
TiDB CloudとMomentoを連携させてRead Asideパターンを実装してみます。

Momento?

このblogでも度々紹介している、
クラウドネイティブな高速キャッシュサービスで、下記のような特徴をもっています。

  • セットアップが簡単
  • プロビジョニングの必要がない(自動でうまくやってくれる)
  • 料金はデータの転送量($0.50/GB)のみ。月50GBまでは無料

TiDB?

TiDB(Titan Distributed Database)は、OSSの分散データベースであり、
シャード間の水平スケーリングと
自動フェイルオーバーの機能を持ちます。
また、MySQLの互換性を持ちながら、大規模なデータ処理と
高可用性を実現しているのが特徴です。

そして、OLTP(トランザクション)をサポートしつつ、
データを行単位(OLTPに有用)と列単位(OLAPに有用)の両方で保存する、
HTAP(Hybrid Transaction Analytical Processing)を実現しています。

つまり、TiDBならば同じDB上でトランザクション処理と
分析処理を並行して処理可能であるということです。
これにより、データのリアルタイムな更新と分析が可能となり、
とても有用な価値を持つといえます。

また、TiDBは分散データベースとして設計されているので、
大規模なデータセットや高トラフィックなアプリに対しても
スケーラビリティを提供します。

その他、分散トランザクション(複数ノードでのトランザクション処理)や
自動シャーディング機能など、いろいろな機能をもっています。
詳しくは公式のドキュメントをご確認ください。

この記事では、TiDBのクラウド版であるTiDB Cloudを使用します。
TiDB CloudはフルマネージドのDBaaS(Database-as-a-Service)です。
TiDBの特性とクラウドの特性、そしてMomentoの特性を考慮すると
とても相性がよい組み合わせになります。

TiDB Cloud & Momento

DBから読み取るデータが必ずしも一貫性を持つ必要がなければ、
consistencyがないデータストアを使うことで
パフォーマンス、可用性、コストについて利点が生まれます。

従来の手法であればそういった改善手段として、
非同期レプリケーションを使うこともありました。
また、データキャッシュとしてインメモリデータストアを使うこともあります。
しかし、データキャッシュを追加することでインフラ管理や運用のコストが増加します。
せっかくTiDB(TiDB CLoud)を使ってシンプルな構成にして管理コストを削減したのであれば
シンプルさと低コストを維持しましょう。

MomentoとTiDBを使用することで、それが実現可能になります。

Environment

  • MacBook Pro (13-inch, M1, 2020)
  • OS : MacOS 13.0.1
  • Node : v18.15.0
  • serverless : Framework Core: 3.33.0 , SDK: 4.3.2

有効なAWSアカウントがあり、設定済みとします。

Setup

TiDB Cloud + Momentoのサンプルを動かすための準備をします。

Momento

Momento Consoleへアクセスし、
キャッシュの作成と認証トークンを取得しておきます。
手順はここあたりをみればできるかと思います。

TiDB Cloud

次にTiDB Cloudの準備です。
アカウントがない場合、ここからSignupします。
Signup後はこのあたりに手順があるので、
適当なクラスターを作成します。
途中でサンプルのDBつくるかきかれるので、そのままサンプルDBをつくってもよいです。

適当なDBを作成したら、
テーブルとデータを作成します。
クラスターを選択してChat2QueryでSQLを実行します。

create table users (ID int(3),Name varchar(50));
insert into users values(1,'taro');

とりあえずシンプルなテーブルとデータ1件を登録します。
次にクラスターのOverview画面の右上にある「Connect」ボタンを押します。
すると、いろいろな接続方法のサンプルが表示されるので、
Node.js用のサンプルを選択してコピーします。
これのデータベース名だけ適切なものに設定すれば、そのままうごきます。

Create serverless program

DBとMomentoの設定ができたので、次はアプリを作成します。
アプリはserverlessフレーワークをつかってAWS Lambdaを実装しましょう。

serverlessはHomebrewでもなんでもよいのでとりあえずインストール。

% brew install serverless

typescript用のテンプレートを指定してプロジェクトを作成し、
必要なモジュールをインストールします。

% serverless create -t aws-nodejs-typescript -p momento-lambda
% cd momento-lambda
% npm install --save @gomomento/sdk
% npm install --save mysql2

TiDBはMySQL互換なのでmysql2モジュールがそのまま使えます。  

実装は↓のような感じです。
デフォルトで生成されたhello/handler.tsを修正していきましょう。

Momentoクライアントの定義です。
今回は認証トークンを文字列で定義してそのまま指定してますが、
本来ならfromEnvironmentVariableで環境変数から取得したり
SecretManagerを使ったりしてセキュアな方法で設定しましょう。

const momento = new CacheClient({
  configuration: Configurations.Laptop.v1(),
  //fromEnvironmentVariableとか安全な方法でやってください
  credentialProvider: CredentialProvider.fromString({authToken: MOMENTO_AUTH_TOKEN}),
  defaultTtlSeconds: 30,
});

なお、TTLは30秒で設定しています。

TiDBのコネクションを取得するための関数です。
↑でコピペした内容をほぼそのまま使います。

async function getConnection() {
  const con =  await mysql.createConnection({
    host: '<host>',
    port: 4000,
    user: '<ユーザー名>',
    password: '<パスワード>',
    database: '<データベース名>',
    ssl: {
      minVersion: 'TLSv1.2',
      rejectUnauthorized: true
    }
  });

  return con;
}

メイン処理実行前にキャッシュを確認し、
見つかればその値を返すデコレータを実装します。

//デコレータ
function MomentoCache(target: any, propertyName: any, descriptor: any) {

  const method = descriptor.value;

  descriptor.value = async function (...args: any) {

      console.log("#### MomentoCache decolator #####");
      const getResponse = await momento.get(cacheName, args[0].body.data_key.toString());
      if (getResponse instanceof CacheGet.Hit) {
        return formatJSONResponse({
          message: "from Momento cache data : " + getResponse.valueString()
        });  
      } else {
        console.log('cache miss or error');
      }

      return await method.apply(target, args);
  };
}

Lambda実行時に渡されたdata_keyの値をMomentoキャッシュから検索し、
見つかればそのまま返します。

↑のデコレータを付与したhello関数(Lmabdaの本体)を定義します。
ここの処理が実行されるときはMomentoで値が見つからなかったときです。

export class HelloClass {
  @MomentoCache
  public static async hello(event) {
    console.log("#### HelloClass.hello dunction #####");
    //キャッシュにないからDBから取得
    const conn = await getConnection();  
    const id:number = event.body.data_key;
    const [rows, fields] = await conn.execute('select name from `users` where `id` = ?', [id]);
    const name = rows[0].name;
    await conn.close();

    //キャッシュセット
    await momento.set(cacheName,id.toString(),name);

    return formatJSONResponse({
      message: "from DB data : " + name
    });
  }
}

かなり雑ですが、Momentoに値がなければTiDBから値を検索して、
キャッシュにセットしてその値を返します。

そしてschema.tsをちょっと修正。

//schema.ts
export default {
  type: "object",
  properties: {
    data_key: { type: 'integer' }
  },
  required: ['data_key']
} as const;

コードを修正したらローカルで確認してみましょう。

src/functions/mock.jsonを↓のように記述して、
ローカルで実行してみます。

{
  "headers": {
    "Content-Type": "application/json"
  },
  "body": "{\"data_key\": 1}"
}

DBへアクセスできてます。キャッシュの期限切れ前に再度実行すると、
キャッシュから値を取得します。

% npx sls invoke local -f hello --path src/functions/hello/mock.json

[2023-07-12T08:43:21.125Z] INFO (Momento: CacheClient2): Creating Momento CacheClient
#### MomentoCache decolator #####
cache miss or error
#### HelloClass.hello function #####
{
    "statusCode": 200,
    "body": "{\"message\":\"from DB data : taro\"}"
}

ローカルで確認できたらデプロイしてみます。
デプロイしたらエンドポイントが表示されるので、それを次のコマンドで指定します。

% npx sls deploy

curlで何度かアクセスしてみると、Lambdaでも動作していることがわかります。

% curl --location --request POST '<URL>/dev/hello' \
--header 'Content-Type: application/json' \
--data-raw '{"data_key": 1}'
{"message":"from DB data : taro"}


% curl --location --request POST '<URL>/dev/hello' \
--header 'Content-Type: application/json' \
--data-raw '{"data_key": 1}'
{"message":"from Momento cache data : taro"}

hadler.tsの全文です。

import { formatJSONResponse } from "@libs/api-gateway";
import { middyfy } from "@libs/lambda";
import {
  CacheGet,
  CacheClient,
  Configurations,
  CredentialProvider,
} from '@gomomento/sdk';

import mysql from 'mysql2/promise';
const cacheName = 'example-cache';
const MOMENTO_AUTH_TOKEN="<認証トークン>";


// momentoクライアント
const momento = new CacheClient({
  configuration: Configurations.Laptop.v1(),
  //fromEnvironmentVariableとか安全な方法でやってください
  credentialProvider: CredentialProvider.fromString({authToken: MOMENTO_AUTH_TOKEN}),
  defaultTtlSeconds: 30,
});


async function getConnection() {
  const con =  await mysql.createConnection({
    host: '<host>',
    port: 4000,
    user: '<ユーザー名>',
    password: '<パスワード>',
    database: '<データベース名>',
    ssl: {
      minVersion: 'TLSv1.2',
      rejectUnauthorized: true
    }
  });

  return con;
}

function MomentoCache(target: any, propertyName: any, descriptor: any) {
  const method = descriptor.value;

  descriptor.value = async function (...args: any) {
      console.log("#### MomentoCache decolator #####");
      const getResponse = await momento.get(cacheName, args[0].body.data_key.toString());
      if (getResponse instanceof CacheGet.Hit) {
        return formatJSONResponse({
          message: "from Momento cache data : " + getResponse.valueString()
        });  
      } else {
        console.log('cache miss or error');
      }

      return await method.apply(target, args);
  };
}


export class HelloClass {
  @MomentoCache
  public static async hello(event) {
    console.log("#### HelloClass.hello dunction #####");
    //キャッシュにないからDBから取得
    const conn = await getConnection();  
    const id:number = event.body.data_key;
    const [rows, fields] = await conn.execute('select name from `users` where `id` = ?', [id]);
    const name = rows[0].name;
    await conn.close();

    //キャッシュセット
    await momento.set(cacheName,id.toString(),name);

    return formatJSONResponse({
      message: "from DB data : " + name
    });
  }
}

export const main = middyfy(HelloClass.hello);

Summary

今回はTiDB CloudとMomentoを組み合わせて
Read Asideを実装してみました。
TiDBもMomentoも自動でスケールし、
シンプルな構成で管理も楽ということで
非常に有用かと思います。

なお、Momentoについてのお問い合わせはこちらです。
こちらもお気軽にお問い合わせください。

References

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.